/*
 * Copyright (c) 2014, Hsu Shih-Chia (schsu01@gmail.com)
 * All rights reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.qood;

import java.io.*;
import java.math.BigDecimal;
import java.sql.*;
import java.util.*;
import java.util.Date;

/**
 * QNew is named QFactory originally,
 * plays an very important role in creating QBuidler, QModel, QWhere, QDB and QSQL.
 *
 * @author SCHsu01
 * @version 1.0
 */
public final class QNew {
  static final int CACHE_SIZE = 0x200;//512

  private QNew() {/*call static method only*/}

  /**
   * Get a QBuilder to create a multi-column QModel in Java Heap (Memory) for better performance.
   *
   * @param initRows estimated row count, if &lt;= 0 then use default
   * @param names    new column names
   * @return an empty QBuilder
   */
  public static QBuilder.Cols builderHeap(int initRows, String... names) {
    return new BuilderHeap(initRows <= 0 ? CACHE_SIZE : initRows, names);
  }

  /**
   * Get a QBuilder to create a multi-column QModel in QFS (File) for large amount of data.
   *
   * @param pathQFS optional file path to store data, if null then use Java Heap (Memory) like {@link #builderHeap}
   * @param names   new column names
   * @return an empty QBuilder
   */
  public static QBuilder.Cols builder(String pathQFS, String... names) {
    return BuilderCol.getInstance(pathQFS, names);
  }

  /**
   * Get a QBuilder to create a 2-column (key~value pair) QModel.
   *
   * @param keyType   type of key column
   * @param valueType type of value column
   * @param <K>       type of key column
   * @param <V>       type of value column
   * @return an empty QBuilder
   */
  public static <K extends Comparable, V> QBuilder.KeyValue<K, V> builderMap(Class<K> keyType, Class<V> valueType) {
    if (null == keyType || null == valueType)
      throw new IllegalArgumentException("invalid BuilderMap<" + keyType + "," + valueType + ">");
    return new BuilderMap<K, V>(new LinkedHashMap<K, V>(), keyType, valueType);
  }

  /**
   * Get a QBuilder to create a QModel by Reflection with defined fields or methods.
   *
   * @param pojoClass type of POJO which contains fields or methods you want
   * @param <T>       type of POJO
   * @return an empty QBuilder
   */
  public static <T> QBuilder.POJO<T> builderPOJO(Class<T> pojoClass) {
    return new BuilderPOJO<T>(pojoClass);
  }

  /**
   * Get a QBuilder to create a bigger QModel by combine 2 or more QModels
   *
   * @param pathQFS optional file path to store data, if null then use Java Heap (Memory)
   * @param names   columns to retrieve data
   * @return an empty QBuilder
   */
  public static QBuilder.Union builderUnion(String pathQFS, String... names) {
    return new BuilderUnion(pathQFS, names);
  }

  /**
   * Create a QModel by predefined converter of JavaBeans.
   *
   * @param convert JavaBean converter contains columns definition
   * @param rows    JavaBeans
   * @param <T>     type of JavaBean
   * @return a new QModel
   */
  public static <T> QModel modelBean(QOutput.Bean<T> convert, T... rows) {
    return new ModelBean<T>(convert, rows);
  }

  /**
   * Create a QModel by a CSV file.
   *
   * @param pathCSV    path of CSV file
   * @param detectType wheter to try first 1000 rows to determine actual data type of each column, default is false.
   * @param charset    how to decode CSV file, default is UTF-8
   * @param pathQFS    optional file path to store data, if null then use Java Heap (Memory)
   * @return a new QModel
   */
  public static QModel modelCSV(String pathCSV, boolean detectType, String charset, String pathQFS) {
    return new CsvParser(detectType, pathCSV, null == charset ? "UTF-8" : charset, pathQFS).toModel();
  }

  /**
   * Create a QModel by a CSV file.
   *
   * @param pathCSV path of CSV file
   * @return a new QModel
   */
  public static QModel modelCSV(String pathCSV) {
    return modelCSV(pathCSV, false, null, null);
  }

  /**
   * Create a QModel by a JDBC ResultSet
   *
   * @param pathQFS   optional file path to store data, if null then use Java Heap (Memory)
   * @param rs        JDBC ResultSet
   * @param rowsLimit limitation of rows
   * @return a new QModel
   * @throws SQLException when load failed
   */
  public static QModel modelJDBC(String pathQFS, ResultSet rs, int rowsLimit) throws SQLException {
    return DBLoader.loadData(pathQFS, rs, rowsLimit);
  }

  /**
   * Create a QModel by a stored QFS file
   *
   * @param pathQFS file path to store data
   * @return a new QModel
   */
  public static QModel modelQFS(String pathQFS) {
    return ModelQFS.getInstance(pathQFS);
  }

  /**
   * Create a WHERE branch
   *
   * @return AND(~)
   */
  public static QWhere.Logic whereAND() {
    return new WhereLogic(null, WhereLogic.MATCH_ALL);
  }

  /**
   * Create a WHERE branch
   *
   * @return OR(~)
   */
  public static QWhere.Logic whereOR() {
    return new WhereLogic(null, WhereLogic.MATCH_ANY);
  }

  /**
   * Create a WHERE branch
   *
   * @return NOT(~)
   */
  public static QWhere.Logic whereNOT() {
    return new WhereLogic(null, WhereLogic.MATCH_NONE);
  }

  /**
   * Create a condition to check if this formula (or column) in given values
   *
   * @param formula column or formula
   * @param <T>     force data type of given values
   * @return IN(~)
   */
  public static <T> QWhere.In<T> whereIn(String formula) {
    return new WhereIn<T>(null, formula);
  }

  /**
   * Create a condition to check if this formula (or column) between given values
   *
   * @param formula column or formula
   * @param <T>     force data type of given values
   * @return ? BETWEEN ~ AND ~
   */
  public static <T extends Comparable> QWhere.Range<T> whereRange(String formula) {
    return new WhereRange<T>(null, formula);
  }

  /**
   * Create a condition to check if the text of this formula (or column) starts/endsWith or contains given texts
   *
   * @param formula column or formula
   * @return ? LIKE ~
   */
  public static QWhere.LikeText whereLike(String formula) {
    return new WhereLike(null, formula);
  }

  /**
   * Create a condition to check if the text of this formula (or column) matches given globbing/regex patterns
   *
   * @param formula column or formula
   * @return ? LIKE ~
   */
  public static QWhere.LikePattern wherePattern(String formula) {
    return new WherePattern(null, formula);
  }

  /**
   * Create a customized filter condition
   *
   * @param rule customized filter, normally extends {@link QWhere.Custom.Template}
   * @return QWhere of your filter rule
   */
  public static QWhere where(QWhere.Custom rule) {
    return new WhereCustom(null, rule);
  }

  /**
   * Create a empty collection of named QModel
   *
   * @return an empty QDB
   */
  public static QDB db() {
    return new DB(null);
  }

  /**
   * Create a empty QSQL (SELECT query)
   *
   * @return an empty QSQL
   */
  public static QSQL sql() {
    return new SQL();
  }
}

abstract class DBLoader<T> {
  static final Map<String, DBLoader> MAP = new HashMap<String, DBLoader>();
  private final Class<T> type;
  private final boolean auto;

  DBLoader(boolean auto, Class<T> type, String... dbTypes) {
    this.auto = auto;
    this.type = type;
    if (null != dbTypes) for (final String dbType : dbTypes) MAP.put(dbType, this);
  }

  DBLoader(Class<T> type, String... dbTypes) {
    this(false, type, dbTypes);
  }

  static QModel loadData(String pathQFS, ResultSet rs, int rowsLimit) throws SQLException {
    rs.setFetchDirection(ResultSet.FETCH_FORWARD);
    rs.setFetchSize(QNew.CACHE_SIZE);
    final ResultSetMetaData mt = rs.getMetaData();
    final int cols = mt.getColumnCount();
    final int[] idx = new int[cols];
    final String[] name = new String[cols], typeName = new String[cols];
    final DBLoader[] jdbc = new DBLoader[cols];
    final Class[] type = new Class[cols];
//    final QBuilder.Cols schema = QNew.builderHeap(cols,
//      "Type", "Scale", "Prec", "Disp", "DB_Type", "JDBC_Type", "Schema", "Catalog", "Name", "Label")
//      .setTypes(Integer.class, Integer.class, Integer.class, Integer.class, String.class, String.class, String.class, String.class, String.class, String.class);
    for (int i = 0, j = 1; i < cols; i = j++) {
      idx[i] = j;
      name[i] = mt.getColumnLabel(j);
      typeName[i] = mt.getColumnClassName(j);
//      schema.add(mt.getColumnType(j), mt.getScale(j), mt.getPrecision(j), mt.getColumnDisplaySize(j),
//        mt.getColumnTypeName(j), typeName[i], mt.getSchemaName(j), mt.getCatalogName(j), mt.getColumnName(j), name[i]);
    }
    final QBuilder.Cols builder = QNew.builder(pathQFS, name);
    for (int i = 0; i < cols; i++) {
      final DBLoader loader = MAP.get(typeName[i]);
      if (null == loader) throw new UnsupportedOperationException(name[i] + "'s type <" + typeName[i] +
        "> not supported yet, please contact the committers or enhance data.qdb.DBLoader<T> yourself");
      type[i] = (jdbc[i] = loader.create(name[i], builder)).type;
    }
    builder.setTypes(type);
    final Object[] row = new Object[cols];//reuse row object
    final long time = System.currentTimeMillis();
    for (int limit = rowsLimit <= 0 ? Integer.MAX_VALUE : rowsLimit; limit-- > 0 && rs.next(); builder.add(row))
      for (int i = 0; i < cols; i++) row[i] = jdbc[i].load(rs, idx[i]);//for each cols
    final long cost = System.currentTimeMillis() - time;
    final QModel result = builder.toModel();
    System.out.println("*** Time Cost = " + cost + " of Loading " + builder.getRowCount() + " row(s)");
//    schema.toModel().debug(-1);
    return result;
  }

  DBLoader create(String name, QBuilder.Cols builder) {
    if (auto) builder.detectType(name);
    return this;//if status needed
  }

  abstract T load(ResultSet rs, int index) throws SQLException;

  private static final class Text extends DBLoader<String> {
    private transient Map<String, String> cache;

    Text(String... dbTypes) {
      super(String.class, dbTypes);
      if (0 == dbTypes.length) cache = new LRUMap<String, String>(QNew.CACHE_SIZE);
    }

    String load(ResultSet rs, int index) throws SQLException {
      final String text = rs.getString(index);
      String cached = cache.get(text);
      if (null == cached) cache.put(text, cached = text);
      return cached;
    }

    @Override
    DBLoader create(String name, QBuilder.Cols builder) {
      return new Text();//no register
    }
  }

  private static final class Model extends DBLoader<QModel> {
    private final String name;

    Model(String... dbTypes) {
      this(dbTypes, null);
    }

    private Model(String[] dbTypes, String name) {
      super(QModel.class, dbTypes);
      this.name = name;
    }

    QModel load(ResultSet rs, int index) throws SQLException {//may be buggy
      final Array array = rs.getArray(index);
      if (null == array) return null;
      final Object[] row = (Object[]) array.getArray();
      final int rows = row.length;
      if (0 == rows) return null;
      final Object[] first = ((Struct) row[0]).getAttributes();
      final BuilderCol b = new BuilderHeap(rows, ModelTrans.getName(name + "_", 1, first.length))
        .setTypes(Col.typeOf(first));
      b.addNoCheck(first);
      for (int i = 1; i < rows; i++) b.addNoCheck(((Struct) row[i]).getAttributes());
      return b.toModel();
    }

    DBLoader create(String name, QBuilder.Cols builder) {
      return new Model(null, name);
    }
  }

  static {
    new Text("java.lang.String");
    new Model("oracle.jdbc.OracleArray");
    new DBLoader<Boolean>(Boolean.class, "java.lang.Boolean") {
      Boolean load(ResultSet rs, int index) throws SQLException {
        final boolean value = rs.getBoolean(index);
        return rs.wasNull() ? null : value;
      }
    };
    new DBLoader<Byte>(Byte.class, "java.lang.Byte") {
      Byte load(ResultSet rs, int index) throws SQLException {
        final byte value = rs.getByte(index);
        return rs.wasNull() ? null : value;
      }
    };
    new DBLoader<Short>(Short.class, "java.lang.Short") {
      Short load(ResultSet rs, int index) throws SQLException {
        final short value = rs.getShort(index);
        return rs.wasNull() ? null : value;
      }
    };
    new DBLoader<Integer>(Integer.class, "java.lang.Integer") {
      Integer load(ResultSet rs, int index) throws SQLException {
        final int value = rs.getInt(index);
        return rs.wasNull() ? null : value;
      }
    };
    new DBLoader<Long>(Long.class, "java.lang.Long") {
      Long load(ResultSet rs, int index) throws SQLException {
        final long value = rs.getByte(index);
        return rs.wasNull() ? null : value;
      }
    };
    new DBLoader<Double>(Double.class, "java.lang.Float", "java.lang.Double") {
      Double load(ResultSet rs, int index) throws SQLException {
        final double value = rs.getDouble(index);
        return rs.wasNull() ? null : value;
      }
    };
    new DBLoader<BigDecimal>(true, BigDecimal.class, "java.math.BigDecimal") {
      BigDecimal load(ResultSet rs, int index) throws SQLException {
        return rs.getBigDecimal(index);
      }
    };
    new DBLoader<Date>(Date.class, "java.sql.Time", "java.sql.Timestamp", "java.sql.Date",
      "oracle.sql.DATE", "oracle.sql.TIMESTAMP", "oracle.sql.TIMESTAMPTZ", "oracle.sql.TIMESTAMPLTZ") {
      Date load(ResultSet rs, int index) throws SQLException {
        final Timestamp ts = rs.getTimestamp(index);
        return null == ts ? null : new Date(ts.getTime());
      }
    };
    new DBLoader<Object>(true, Object.class, "java.sql.Blob", "oracle.sql.BLOB", "oracle.jdbc.OracleBlob") {
      Object load(ResultSet rs, int index) throws SQLException {
        final Blob blob = rs.getBlob(index);
        if (null == blob || blob.length() <= 0) return null;
        ObjectInputStream in = null;
        try {
          return (in = new ObjectInputStream(blob.getBinaryStream())).readObject();
        } catch (SQLException e) {
          e.printStackTrace();
          throw e;
        } catch (IOException e) {
          e.printStackTrace();
          throw new SQLException(e);
        } catch (ClassNotFoundException e) {
          final StringWriter sw = new StringWriter();
          e.printStackTrace(new PrintWriter(sw));
          return sw.toString();
        } finally {
          if (null != in) try {
            in.close();
          } catch (IOException ignored) {/*do nothing*/}
        }
      }
    };
    new DBLoader<String>(String.class, "java.sql.Clob", "oracle.sql.CLOB", "oracle.jdbc.OracleClob", "byte[]", "oracle.sql.ROWID") {
      String load(ResultSet rs, int index) throws SQLException {
        return rs.getString(index);//no cache
      }
    };
    new DBLoader<String>(String.class, "oracle.jdbc.OracleOpaque") {
      String load(ResultSet rs, int index) throws SQLException {
        return String.valueOf(rs.getObject(index));//no cache
      }
    };
  }
}

final class CsvParser {
  private final boolean detectType;
  private final Collection<String> cell = new ArrayList<String>(), breaks = new ArrayList<String>();
  private final String pathQFS;
  private transient Map<String, String>[] cache;
  private QBuilder.Cols builder;

  CsvParser(boolean detectType, String pathCSV, String charset, String pathQFS) {
    this.detectType = detectType;
    BufferedInputStream bi = null;
    BufferedReader r = null;
    this.pathQFS = pathQFS;
    try {
      bi = new BufferedInputStream(new FileInputStream(pathCSV));
      r = new BufferedReader(new InputStreamReader(bi, charset));
      for (String line; null != (line = r.readLine()); ) if (line.length() > 0) parse(line);
    } catch (IOException e) {
      throw new IllegalStateException(e);
    } finally {
      try {
        if (null != r) r.close();
        if (null != bi) bi.close();
      } catch (IOException ignored) {
      }
    }
  }

  /**
   * @param parts eg: new String[]{"\"A","B","","\"","C"}
   * @param from  eg: 0
   * @param to    eg: 3
   * @return eg: "A,B,,"
   */
  private static String merge(String[] parts, int from, int to) {
    final StringBuilder sb = new StringBuilder(parts[from]);
    for (int i = from + 1; i <= to; i++)
      sb.append(',').append(parts[i]);
    return sb.toString();
  }

  private static boolean isQuote(String p, int pos) {
    return '"' == p.charAt(pos);
  }

  private void add(String s) {
    final int index = cell.size();
    final String result;
    if (null == cache) result = s.replaceAll("\"\"", "\"");
    else {//get from cache
      Map<String, String> lru = cache[index];
      if (null == lru) lru = cache[index] = new LRUMap<String, String>(QNew.CACHE_SIZE);
      String cached = lru.get(s);
      if (null == cached) lru.put(s, cached = s.replaceAll("\"\"", "\""));
      result = cached;
    }
    cell.add(result);
  }

  /**
   * @param line "A,B,,",C => ["A, B, , ", C]
   */
  @SuppressWarnings({"unchecked"})
  private void parse(String line) {
    final String[] part = StringUtil.split(line, ",");
    int i = -1, n = part.length, merge = breaks.size() > 0 ? 0 : -1;
    while (++i < n) {
      final String p = part[i];
      final int pLen = p.length(), pLast = pLen - 1;
      if (0 == pLen) {
        if (merge < 0) cell.add(null);
        //else continue;
      } else if (merge >= 0) {
        if (isQuote(p, pLast)) {
          final String merged = merge(part, merge, i);
          final int mergedLast = merged.length() - 1;
          if (breaks.isEmpty())//no break
            add(merged.substring(1, mergedLast));
          else {//join lines
            breaks.add(merged.substring(0, mergedLast));
            add(StringUtil.join(breaks.iterator(), "\\n").toString());//won't intern
            breaks.clear();
          }
          merge = -1;//close
        }//else continue;
      } else if (isQuote(p, 0)) {
        if (isQuote(p, pLast)) add(p.substring(1, pLast));
        else merge = i;
      } else add(p);
    }
    if (merge >= 0) {
      final String merged = merge(part, merge, n - 1);
      breaks.add(breaks.isEmpty() ? merged.substring(1) : merged);
    } else {//line complete
      final int values = cell.size();
      final String[] value = cell.toArray(new String[values]);
      if (null == builder) {//initial
        final Class[] type = new Class[values];
        Arrays.fill(type, String.class);
        builder = QNew.builder(pathQFS, value).setTypes(type);
        if (detectType) builder.detectType(value);
        cache = new Map[values];
      } else builder.add((Object[]) value);//treat String[] as vararg's parameters, not 1st parameter only
      cell.clear();
    }
  }

  QModel toModel() {
    return null == builder ? null : builder.toModel();
  }
}